/**
 * The main bootstrap code for loading pyodide.
 */
import {
  calculateDirname,
  loadScript,
  initNodeModules,
  resolvePath,
  loadLockFile,
  calculateInstallBaseUrl,
} from "./compat";

import { createSettings } from "./emscripten-settings";
import { version as version_ } from "./version";

import type { PyodideAPI } from "./api.js";
import type {
  TypedArray,
  Module,
  PackageData,
  FSType,
  Lockfile,
} from "./types";
import type { EmscriptenSettings } from "./emscripten-settings";
import type { SnapshotConfig } from "./snapshot";
import { withTrailingSlash } from "./common/path";
export type { PyodideAPI, TypedArray, PyodideAPI as PyodideInterface };
export type { LockfileInfo, LockfilePackage, Lockfile } from "./types";

export { type PackageData };

/**
 * The Pyodide version.
 *
 * The version here is a Python version, following :pep:`440`. This is different
 * from the version in ``package.json`` which follows the node package manager
 * version convention.
 */
export const version: string = version_;

declare function _createPyodideModule(
  settings: EmscriptenSettings,
): Promise<Module>;

// BUILD_ID is generated from hashing together pyodide.asm.js and
// pyodide.asm.wasm in esbuild.config.outer.mjs
//
// It is used to check that memory snapshots were generated by the same build of
// the runtime that is trying to use them. Attempting to use a snapshot from a
// different build will fail badly. See logic in snapshot.ts.
declare const BUILD_ID: string;

/**
 * See documentation for loadPyodide.
 * @hidden
 */
export type ConfigType = {
  indexURL: string;
  packageCacheDir: string;
  lockFileContents: Lockfile | string | Promise<Lockfile | string>;
  fullStdLib?: boolean;
  stdLibURL?: string;
  stdin?: () => string;
  stdout?: (msg: string) => void;
  stderr?: (msg: string) => void;
  jsglobals?: object;
  _sysExecutable?: string;
  args: string[];
  fsInit?: (FS: FSType, info: { sitePackages: string }) => Promise<void>;
  env: { [key: string]: string };
  packages: string[];
  _makeSnapshot: boolean;
  enableRunUntilComplete: boolean;
  checkAPIVersion: boolean;
  BUILD_ID: string;
  packageBaseUrl?: string;
  cdnUrl: string;
};

/**
 * Load the main Pyodide wasm module and initialize it.
 *
 * @returns The :ref:`js-api-pyodide` module.
 * @example
 * async function main() {
 *   const pyodide = await loadPyodide({
 *     fullStdLib: true,
 *     stdout: (msg) => console.log(`Pyodide: ${msg}`),
 *   });
 *   console.log("Loaded Pyodide");
 * }
 * main();
 */
export async function loadPyodide(
  options: {
    /**
     * The URL from which Pyodide will load the main Pyodide runtime and
     * packages. It is recommended that you leave this unchanged, providing an
     * incorrect value can cause broken behavior.
     *
     * Default: The url that Pyodide is loaded from with the file name
     * (``pyodide.js`` or ``pyodide.mjs``) removed.
     */
    indexURL?: string;

    /**
     * The file path where packages will be cached in node. If a package
     * exists in ``packageCacheDir`` it is loaded from there, otherwise it is
     * downloaded from the JsDelivr CDN and then cached into ``packageCacheDir``.
     * Only applies when running in node; ignored in browsers.
     *
     * Default: same as indexURL
     */
    packageCacheDir?: string;

    /**
     * The URL from which Pyodide will load the Pyodide ``pyodide-lock.json`` lock
     * file. You can produce custom lock files with :py:func:`micropip.freeze`.
     * Default: ```${indexURL}/pyodide-lock.json```
     */
    lockFileURL?: string;
    /**
     * The contents of a lockfile. If a string, it should be valid json and
     * ``JSON.parse()`` should return a ``Lockfile`` instance. See
     * :js:interface:`~pyodide.Lockfile` for the schema.
     */
    lockFileContents?: Lockfile | string | Promise<Lockfile | string>;
    /**
     * The base url relative to which a relative value of
     * :js:attr:`~pyodide.LockfilePackage.file_name` is interpreted. If
     * ``lockfileContents`` is provided, then ``lockFileContents`` must be
     * provided explicitly in order to install packages with relative paths.
     *
     * Otherwise, the default is calculated as follows:
     *
     * 1. If `lockFileURL` contains a ``/``, the default is everything before the last
     *    ``/`` in ``lockFileURL``.
     * 2. If in the browser, the default is ``location.toString()``.
     * 3. Otherwise, the default is `'.'`.
     */
    packageBaseUrl?: string;
    /**
     * Load the full Python standard library. Setting this to false excludes
     * unvendored modules from the standard library.
     * Default: ``false``
     */
    fullStdLib?: boolean;
    /**
     * The URL from which to load the standard library ``python_stdlib.zip``
     * file. This URL includes the most of the Python standard library. Some
     * stdlib modules were unvendored, and can be loaded separately
     * with ``fullStdLib: true`` option or by their package name.
     * Default: ```${indexURL}/python_stdlib.zip```
     */
    stdLibURL?: string;
    /**
     * Override the standard input callback. Should ask the user for one line of
     * input. The :js:func:`pyodide.setStdin` function is more flexible and
     * should be preferred.
     */
    stdin?: () => string;
    /**
     * Override the standard output callback. The :js:func:`pyodide.setStdout`
     * function is more flexible and should be preferred in most cases, but
     * depending on the ``args`` passed to ``loadPyodide``, Pyodide may write to
     * stdout on startup, which can only be controlled by passing a custom
     * ``stdout`` function.
     */
    stdout?: (msg: string) => void;
    /**
     * Override the standard error output callback. The
     * :js:func:`pyodide.setStderr` function is more flexible and should be
     * preferred in most cases, but depending on the ``args`` passed to
     * ``loadPyodide``, Pyodide may write to stdout on startup, which can only
     * be controlled by passing a custom ``stdout`` function.
     */
    stderr?: (msg: string) => void;
    /**
     * The object that Pyodide will use for the ``js`` module.
     * Default: ``globalThis``
     */
    jsglobals?: object;
    /**
     * Determine the value of ``sys.executable``.
     * @ignore
     */
    _sysExecutable?: string;
    /**
     * Command line arguments to pass to Python on startup. See `Python command
     * line interface options
     * <https://docs.python.org/3.10/using/cmdline.html#interface-options>`_ for
     * more details. Default: ``[]``
     */
    args?: string[];
    /**
     * Environment variables to pass to Python. This can be accessed inside of
     * Python at runtime via :py:data:`os.environ`. Certain environment variables change
     * the way that Python loads:
     * https://docs.python.org/3.10/using/cmdline.html#environment-variables
     * Default: ``{}``.
     * If ``env.HOME`` is undefined, it will be set to a default value of
     * ``"/home/pyodide"``
     */
    env?: { [key: string]: string };
    /**
     * A list of packages to load as Pyodide is initializing.
     *
     * This is the same as loading the packages with
     * :js:func:`pyodide.loadPackage` after Pyodide is loaded except using the
     * ``packages`` option is more efficient because the packages are downloaded
     * while Pyodide bootstraps itself.
     */
    packages?: string[];
    /**
     * Opt into the old behavior where :js:func:`PyProxy.toString() <pyodide.ffi.PyProxy.toString>`
     * calls :py:func:`repr` and not :py:class:`str() <str>`. Deprecated.
     * @deprecated
     */
    pyproxyToStringRepr?: boolean;
    /**
     * Make loop.run_until_complete() function correctly using stack switching.
     * Default: ``true``.
     */
    enableRunUntilComplete?: boolean;
    /**
     * If true (default), throw an error if the version of Pyodide core does not
     * match the version of the Pyodide js package.
     */
    checkAPIVersion?: boolean;
    /**
     * This is a hook that allows modification of the file system before the
     * main() function is called and the intereter is started. When this is
     * called, it is guaranteed that there is an empty site-packages directory.
     * @experimental
     */
    fsInit?: (FS: FSType, info: { sitePackages: string }) => Promise<void>;
    /**
     * Opt into the old behavior where JavaScript `null` is converted to `None`
     * instead of `jsnull`. Deprecated.
     * @deprecated
     */
    convertNullToNone?: boolean;
    /** @ignore */
    _makeSnapshot?: boolean;
    /** @ignore */
    _loadSnapshot?:
      | Uint8Array
      | ArrayBuffer
      | PromiseLike<Uint8Array | ArrayBuffer>;
    /** @ignore */
    _snapshotDeserializer?: (obj: any) => any;
  } = {},
): Promise<PyodideAPI> {
  if (options.lockFileContents && options.lockFileURL) {
    throw new Error("Can't pass both lockFileContents and lockFileURL");
  }
  await initNodeModules();

  // Relative paths cause havoc.
  let indexURL = options.indexURL || (await calculateDirname());
  indexURL = withTrailingSlash(resolvePath(indexURL));
  const options_ = options as ConfigType;

  options_.packageBaseUrl = withTrailingSlash(options_.packageBaseUrl);
  // cdnUrl only for node. withTrailingSlash is a no-op, but just in case to prevent future human errors.
  options_.cdnUrl = withTrailingSlash(
    options_.packageBaseUrl ??
      `https://cdn.jsdelivr.net/pyodide/v${version}/full/`,
  );

  if (!options.lockFileContents) {
    const lockFileURL = options.lockFileURL ?? indexURL + "pyodide-lock.json";
    options_.lockFileContents = loadLockFile(lockFileURL);
    // packageBaseUrl isn't present, try using base location of lockFileUrl. If
    // lockFileURL is relative, use location as the base URL.
    options_.packageBaseUrl ??= calculateInstallBaseUrl(lockFileURL);
  }
  options_.indexURL = indexURL;

  if (options_.packageCacheDir) {
    options_.packageCacheDir = withTrailingSlash(
      resolvePath(options_.packageCacheDir),
    );
  }

  const default_config = {
    fullStdLib: false,
    jsglobals: globalThis,
    stdin: globalThis.prompt ? globalThis.prompt : undefined,
    args: [],
    env: {},
    packages: [],
    packageCacheDir: options_.packageBaseUrl,
    enableRunUntilComplete: true,
    checkAPIVersion: true,
    BUILD_ID,
  };
  const config = Object.assign(default_config, options_) as ConfigType;
  config.env.HOME ??= "/home/pyodide";
  /**
   * `PyErr_Print()` will call `exit()` if the exception is a `SystemError`.
   * This shuts down the Python interpreter, which is a change in behavior from
   * what happened before. In order to avoid this, we set the `inspect` config
   * parameter which prevents `PyErr_Print()` from calling `exit()`. Except in
   * the cli runner, we actually do want to exit. So set default to true and in
   * cli runner we explicitly set it to false.
   */
  config.env.PYTHONINSPECT ??= "1";
  const emscriptenSettings = createSettings(config);
  const API = emscriptenSettings.API;
  API.lockFilePromise = Promise.resolve(options_.lockFileContents);

  // If the pyodide.asm.js script has been imported, we can skip the dynamic import
  // Users can then do a static import of the script in environments where
  // dynamic importing is not allowed or not desirable, like module-type service workers
  if (typeof _createPyodideModule !== "function") {
    const scriptSrc = `${config.indexURL}pyodide.asm.js`;
    await loadScript(scriptSrc);
  }

  let snapshot: Uint8Array | undefined = undefined;
  if (options._loadSnapshot) {
    const snp = await options._loadSnapshot;
    if (ArrayBuffer.isView(snp)) {
      snapshot = snp;
    } else {
      snapshot = new Uint8Array(snp);
    }
    emscriptenSettings.noInitialRun = true;
    // @ts-ignore
    emscriptenSettings.INITIAL_MEMORY = snapshot.length;
  }

  // _createPyodideModule is specified in the Makefile by the linker flag:
  // `-s EXPORT_NAME="'_createPyodideModule'"`
  const Module = await _createPyodideModule(emscriptenSettings);
  // Handle early exit
  if (emscriptenSettings.exitCode !== undefined) {
    throw new Module.ExitStatus(emscriptenSettings.exitCode);
  }
  if (options.pyproxyToStringRepr) {
    API.setPyProxyToStringMethod(true);
  }
  if (options.convertNullToNone) {
    API.setCompatNullToNone(true);
  }

  if (API.version !== version && config.checkAPIVersion) {
    throw new Error(`\
Pyodide version does not match: '${version}' <==> '${API.version}'. \
If you updated the Pyodide version, make sure you also updated the 'indexURL' parameter passed to loadPyodide.\
`);
  }
  // Disable further loading of Emscripten file_packager stuff.
  Module.locateFile = (path: string) => {
    if (path.endsWith(".so")) {
      throw new Error(`Failed to find dynamic library "${path}"`);
    }
    throw new Error(`Unexpected call to locateFile("${path}")`);
  };

  let snapshotConfig: SnapshotConfig | undefined = undefined;
  if (snapshot) {
    snapshotConfig = API.restoreSnapshot(snapshot);
  }
  // runPython works starting after the call to finalizeBootstrap.
  const pyodide = API.finalizeBootstrap(
    snapshotConfig,
    options._snapshotDeserializer,
  );
  API.sys.path.insert(0, "");

  API._pyodide.set_excepthook();
  await API.packageIndexReady;
  // I think we want this initializeStreams call to happen after
  // packageIndexReady? I don't remember why.
  API.initializeStreams(config.stdin, config.stdout, config.stderr);
  return pyodide;
}
